fix: optimize serving static files#1848
Conversation
6feb7d9 to
99d95f6
Compare
| path: string, | ||
| public readonly spec: OperationObject, | ||
| protected readonly _handler: Function, | ||
| protected readonly _handler: Function, // <-- doesn't this Function have a signature? |
There was a problem hiding this comment.
The handler function accepts a list of parameters as described in its OpenAPI specification.
See invokeHandler below and our docs: https://loopback.io/doc/en/lb4/Routes.html#using-partial-openapi-spec-fragments
| // LB4's static assets serving router | ||
| const staticAssetsRouter = new Route('get', '*', {responses: {}}, () => { | ||
| console.log('>> staticAssetsRouter HANDLER'); | ||
| // @bajtos How do we access req and res here? |
There was a problem hiding this comment.
You cannot access the request/response from a handler route (a Route instance). In my comment #1784 (comment), I was showing how to create a new kind of RouteEntry (a new class implementing RouteEntry interface). RouteEntry's method invokeHandler does have access to the request/response objects.
// somewhere in the code setting up RoutingTable
// the anonymous RouteEntry object should be refactored into a class
this.routingTable.catchAllRoute = <RouteEntry>{
verb: 'get',
path: '*',
spec: { /* TODO: this route should be excluded from the spec */ },
updateBindings(requestContext: Context) {
// no-op
},
invokeHandler(
{request, response}: Context,
args: OperationArgs,
): Promise<OperationRetval> {
return this._handleStaticAssetRequest(request, response);
}
describe(): string {
return "final route to handle static assets";
}
};2c9ec52 to
acb0089
Compare
bajtos
left a comment
There was a problem hiding this comment.
I don't like the fact that in the current design, the implementation of StaticAsset routing is scattered across multiple files and layers of abstraction (server, router, routing table, StaticRoute). Let's try to find a way how to keep most of the implementation in one or two places please - see my comments below.
| args: OperationArgs, | ||
| ): Promise<OperationRetval> { | ||
| const req = await requestContext.get(RestBindings.Http.REQUEST); | ||
| const res = await requestContext.get(RestBindings.Http.RESPONSE); |
There was a problem hiding this comment.
Let's use context.request and context.response, it should be faster than obtaining the values via Context bindings.
| ): Promise<OperationRetval> { | ||
| const req = await requestContext.get(RestBindings.Http.REQUEST); | ||
| const res = await requestContext.get(RestBindings.Http.RESPONSE); | ||
| return this._handler(req, res); |
There was a problem hiding this comment.
I am proposing to simplify the design here. Since there will be always at most one StaticRoute instance registered with a server, there is no need to make the handler configurable. Let's move the code dealing with _routerForStaticAssets here into invokeHandler.
Ideally, I'd like to see _routerForStaticAssets to be a member property of StaticRoute.
| return reject(err); | ||
| } | ||
| // Express router called next, which means no route was matched | ||
| return reject(new HttpErrors.NotFound()); |
There was a problem hiding this comment.
Let's be more verbose here. See how we deal with 404 errors now:
| }, | ||
| ); | ||
|
|
||
| this.httpHandler.registerRoute(staticAssetsRouter); |
There was a problem hiding this comment.
IIUC, this is relying on the fact that the code paths invoked here is bypassing path validations and that our routing table will pass the path * to Express router without modifications.
I find that rather brittle, let's find a different solution please.
I am thinking about the following implementation of RoutingTable#find:
class RoutingTable {
// ...
find(request: Request): ResolvedRoute {
debug('Finding route %s for %s %s', request.method, request.path);
const found = this._router.find(request);
if (found) {
debug('Route matched: %j', found);
return found;
}
debug('No API route found for %s %s, trying to find a static assets', request.method, request.path);
return this._staticAssetsRoute;
}
}| readonly schemas: SchemasObject; | ||
| } | ||
|
|
||
| export class StaticRoute implements RouteEntry { |
There was a problem hiding this comment.
Let's call this StaticAssetsRoute. The name "static route" can be also interpreted as a route that's static (e.g. does not accept any path parameters).
|
Thanks @bajtos, I will be updating the implementation based on your feedback. |
|
|
||
| validateApiPath(route.path); | ||
| this._router.add(route); | ||
| if (route.path === '*') { |
There was a problem hiding this comment.
This is a bit hacky to test path === '*'. The special route is also added to the underlying router, which might cause unexpected results.
|
@hacksparrow please let me know when this pull request is ready for another review. Also the title says |
|
@bajtos I will let you now. There is API spec generation side-effect, please consider it WIP till I remove it. |
|
@hacksparrow are you referring to the following part of #1784 (comment)? It would be best to open a new pull request for that, to make the review easier and get the new flag lander faster.
const spec: OperationObject = {
'x-internal': true,
description: 'return all customers',
parameters: [
// ...
],
responses: {
// ...
},
};
|
|
@bajtos yes that's what I am referring too. Let me add that feature first then. Breakpoints jumping locations and disappearing is making debugging really hard. |
Optimize serving static files
review 1
|
|
||
| validateApiPath(route.path); | ||
| this._router.add(route); | ||
| if (route.path === '/*') { |
There was a problem hiding this comment.
@raymondfeng you had some reservations about this.
This is a bit hacky to test path === '*'. The special route is also added to the underlying router, which might cause unexpected results.
Can you elaborate the concerns, so that we can start a discussion?
There was a problem hiding this comment.
On line 162, the special route is added to the router implementation, which probably doesn't know /*.
There was a problem hiding this comment.
I am still not able to see what the potential problem is. We run validateApiPath(route.path) and /* is a valid key to the static assets router. What am I missing?
There was a problem hiding this comment.
It's not helpful to have /* in the router which won't match any requests.
There was a problem hiding this comment.
In your implementation, the static asset route is a special one outside of the Router responsibility. It serves as the last matching candidate.
There was a problem hiding this comment.
So do we use a non-path key, like path = 'catch-all-router'? We'll have to skip path validation in that case. What are your suggestions?
@bajtos what are your thoughts on this?
There was a problem hiding this comment.
I'm fine to have /* as the path value, but don't call this._router.add(route);.
| requestHandler: HttpRequestListener; | ||
| } | ||
|
|
||
| interface ExpressRouter extends express.Router { |
There was a problem hiding this comment.
ExpressRouter is used in rest.server.ts and routing-table.ts, and defined in both the files. What is a good location to export it from to avoid duplication?
Having to extend express.Router because its definition file does not include the handle method for it. I wonder why.
There was a problem hiding this comment.
Please note that express.Router itself is the handle function.
There was a problem hiding this comment.
src/types.ts is a good place to define common interfaces
There was a problem hiding this comment.
As @raymondfeng said, express.Router is a callable function. It extends IRouter which extends RequestHandler which is defined as follows:
export interface RequestHandler {
// tslint:disable-next-line callable-types (This is extended from and can't extend from a type alias in ts<2.2
(req: Request, res: Response, next: NextFunction): any;
}| if (!this._routerForStaticAssets) { | ||
| this._routerForStaticAssets = express.Router(); | ||
| this._expressApp.use(this._routerForStaticAssets); | ||
| this._routerForStaticAssets = express.Router() as ExpressRouter; |
There was a problem hiding this comment.
I don't think this is a valid cast as express.Router() returns a function which is a handler.
bajtos
left a comment
There was a problem hiding this comment.
The implementation looks mostly reasonable, let's improve the way how StaticAssetsRoute is integrated with server/handler/router parts.
| requestHandler: HttpRequestListener; | ||
| } | ||
|
|
||
| interface ExpressRouter extends express.Router { |
There was a problem hiding this comment.
As @raymondfeng said, express.Router is a callable function. It extends IRouter which extends RequestHandler which is defined as follows:
export interface RequestHandler {
// tslint:disable-next-line callable-types (This is extended from and can't extend from a type alias in ts<2.2
(req: Request, res: Response, next: NextFunction): any;
}|
|
||
| protected _expressApp: express.Application; | ||
| protected _routerForStaticAssets: express.Router; | ||
| protected _routerForStaticAssets: ExpressRouter; |
There was a problem hiding this comment.
Let's move the implementation details of dealing with static assets down to StaticAssetsRoute please.
Ideally, I'd like to see RestServer#static implemented as a thin wrapper delegating the registration of static-asset middleware to StaticAssetsRoute and/or our HttpHandler class.
See also my earlier #1848 (comment)
There was a problem hiding this comment.
@bajtos Calling app.static after the server has started should still set up the static assets route successfully. I tried to understand what you are suggesting and moved some pieces around, but resulted in app.static losing functionality after the app started.
Can you elaborate more on your suggestion, please? What does the outline of RestServer#static look like?
There was a problem hiding this comment.
Let's wait until you have addressed the other comments and then I can try to shuffle the code around myself, I think it will be the fastest option.
There was a problem hiding this comment.
I had addressed all others, except this one. Please refresh to see the changes.
| const onFinished = () => resolve(); | ||
| response.once('finish', onFinished); | ||
| this._routerForStaticAssets.handle(request, response, (err: Error) => { | ||
| if (err) { |
There was a problem hiding this comment.
When the router called us back, then please remove onFinished listener to avoid resolving/rejecting the promise twice and also to avoid a potential (memory) leak.
There was a problem hiding this comment.
It's a once, do we still need to remove it?
There was a problem hiding this comment.
Yes, because when the static asset cannot be found or a ReadStream cannot be created, then the finish event is not fired within the timeframe of this async method.
I guess one could argue that the finish method will be eventually fired because the HTTP response will be finished by other parts of the framework eventually, but I find it very brittle and hacky to rely on some other part of our codebase to trigger "finish". See also Action at a distance anti-pattern.
| updateBindings(requestContext: Context): void { | ||
| // no-op | ||
| } | ||
| async invokeHandler( |
There was a problem hiding this comment.
No need to use async if you are not calling await.
| args: OperationArgs, | ||
| ): Promise<OperationRetval> { | ||
| const {request, response} = requestContext; | ||
| return this._handler(request, response); |
There was a problem hiding this comment.
Have you considered inlining this._handler inside invokeHandler?
class StaticAssetsRoute /*...*/ {
//...
invokeHandler(
{request, response}: RequestContext,
args: OperationArgs,
): Promise<OperationRetval> {
return new Promise((resolve, reject) => {
const onFinished = () => resolve();
response.once('finish', onFinished);
// ...
});
}
//...
}| }); | ||
|
|
||
| it('serve static assets if matches before other routes', async () => { | ||
| it('does not serve static assets even if matches before other routes', async () => { |
There was a problem hiding this comment.
I am proposing to rephrase this test name as follows:
it('gives precedence to API routes over static assets')review 2
|
@hacksparrow I pushed a new commit f516b31 to further clean up the implementation, PTAL. |
|
One more improvement: 79b268c |
bajtos
left a comment
There was a problem hiding this comment.
LGTM. Please make sure to clean up the git history before landing.
It would be great if you could get @raymondfeng's approval too.
|
@hacksparrow hmm, the code coverage data suggests that we are missing the following test:
(This triggers a different code path than for a pure API server with no static assets.) Could you please add it as part of this pull request? |
|
Will do. |
Tests for RestApplication
|
@hacksparrow I merged the PR so that we can do a release soon. |
Optimize serving static files
See #1784 and #1785
Checklist
npm testpasses on your machinepackages/cliwere updatedexamples/*were updated